﻿(*
    this module helps android developers to publish automatically their apks

    useful links:

    https://developers.google.com/accounts/docs/OAuth2ServiceAccount#formingheader
    https://developers.google.com/android-publisher/api-ref/edits/insert

    note:
        I would like to use a JsonProvider to parse json but i don't know if it causes a problem with dependency "FSharp.Data.DesignTime.dll"
        So i used Newtonsoft.Json
*)
[<System.Obsolete("This API is obsolete. There is no alternative in FAKE 5 yet. You can help by porting this module.")>]
module Fake.AndroidPublisher

open System
open System.Security.Cryptography
open System.Security.Cryptography.X509Certificates
open System.Text
open System.Net
open System.Collections.Specialized
open System.Threading
open System.IO
open System.Diagnostics
open Newtonsoft.Json
open ICSharpCode.SharpZipLib.Zip
open ICSharpCode.SharpZipLib.Core
open System.Xml.Linq

[<System.Obsolete("This API is obsolete. There is no alternative in FAKE 5 yet. You can help by porting this module.")>]
type AndroidPublishConfig = {
    Certificate: X509Certificate2;
    PackageName: string;
    AccountId: string;
    Apk: string;
}

[<CLIMutable>]
[<System.Obsolete("This API is obsolete. There is no alternative in FAKE 5 yet. You can help by porting this module.")>]
type AndroidPublishParams = {
    Track: string;
    Config: AndroidPublishConfig;
}

[<System.Obsolete("This API is obsolete. There is no alternative in FAKE 5 yet. You can help by porting this module.")>]
let ProductionSettings = { Track = "production"; Config = { Certificate = null; PackageName = null; AccountId = null; Apk = null; } }
[<System.Obsolete("This API is obsolete. There is no alternative in FAKE 5 yet. You can help by porting this module.")>]
let AlphaSettings = { ProductionSettings with Track = "alpha"; }
[<System.Obsolete("This API is obsolete. There is no alternative in FAKE 5 yet. You can help by porting this module.")>]
let BetaSettings = { ProductionSettings with Track = "beta"; }
[<System.Obsolete("This API is obsolete. There is no alternative in FAKE 5 yet. You can help by porting this module.")>]
let RolloutSettings = { ProductionSettings with Track = "rollout"; }

type private ServiceCredentials = { 
    Certificate: X509Certificate2;
    AccountId: string; //"xxxxxxx@developer.gserviceaccount.com"
}

type private ServiceSession = {
    Token: String;
    TokenType: String;
    Expiry: DateTime;
}

[<CLIMutable>]
type private JwtHeader = {
    [<JsonProperty("alg")>]
    Algo:string;
    [<JsonProperty("typ")>]
    Type: string;
}

[<CLIMutable>]
type private JwtClaimSet = {
    [<JsonProperty("iss")>]
    AccountId: string;
    [<JsonProperty("scope")>]
    Scope: string;
    [<JsonProperty("aud")>]
    Audience: string;
    [<JsonProperty("exp")>]
    Expiry: int64;
    [<JsonProperty("iat")>]
    Issue: int64;
}

[<CLIMutable>]
type private AuthReply = {
    [<JsonProperty("access_token")>]
    AccessToken: String;
    [<JsonProperty("token_type")>]
    TokenType: String;
    [<JsonProperty("expires_in")>]
    ExpiresIn: int32;
}

[<CLIMutable>]
[<System.Obsolete("This API is obsolete. There is no alternative in FAKE 5 yet. You can help by porting this module.")>]
type EditResourceModel = { 
    [<JsonProperty("id")>]
    Id: string;
    [<JsonProperty("expiryTimeSeconds")>]
    Expiry: int64;
}

[<CLIMutable>]
[<System.Obsolete("This API is obsolete. There is no alternative in FAKE 5 yet. You can help by porting this module.")>]
type ApkVersion = {
    [<JsonProperty("versionCode")>]
    Code: int;
}

[<CLIMutable>]
[<System.Obsolete("This API is obsolete. There is no alternative in FAKE 5 yet. You can help by porting this module.")>]
type AppEditListApksResult = {
    [<JsonProperty("apks")>]
    Apks: ApkVersion list
}

[<CLIMutable>]
[<System.Obsolete("This API is obsolete. There is no alternative in FAKE 5 yet. You can help by porting this module.")>]
type TrackModel = {
    [<JsonProperty("versionCodes")>]
    VersionCodes: int list;
    [<JsonProperty("track")>]
    Track: string;
//    [<JsonProperty("userFraction")>]
//    UserFraction: float;
}

type private Result<'t> = {
    Content: 't option;
    Error: string option;
}

type private HttpClient() =
    inherit WebClient()

    override __.GetWebRequest (address:Uri) =
        let rq = base.GetWebRequest address
        rq.Timeout <- int32 <| TimeSpan.FromHours(1.).TotalMilliseconds
        rq

    member x.CreateRequest (address:Uri) =
        x.GetWebRequest (address)

[<System.Obsolete("This API is obsolete. There is no alternative in FAKE 5 yet. You can help by porting this module.")>]
let mutable public AndroidPublisherScope = "https://www.googleapis.com/auth/androidpublisher"
[<System.Obsolete("This API is obsolete. There is no alternative in FAKE 5 yet. You can help by porting this module.")>]
let mutable public TokenServerUrl = "https://www.googleapis.com/oauth2/v3/token"
[<System.Obsolete("This API is obsolete. There is no alternative in FAKE 5 yet. You can help by porting this module.")>]
let mutable public AndroidPublisherBaseUrl = "https://www.googleapis.com/androidpublisher/v2/applications"
[<System.Obsolete("This API is obsolete. There is no alternative in FAKE 5 yet. You can help by porting this module.")>]
let mutable public AndroidUploadApkBaseUrl = "https://www.googleapis.com/upload/androidpublisher/v2/applications"

let private ServiceAccountHeader = {Algo = "RS256"; Type = "JWT"}

let private toJson = JsonConvert.SerializeObject
let private fromJson<'t> v = JsonConvert.DeserializeObject<'t>(v)

[<System.Obsolete("This API is obsolete. There is no alternative in FAKE 5 yet. You can help by porting this module.")>]
let PublishApk (param:AndroidPublishParams)= 

    let getZipEntry (filepath, path) =
        let clean (name:string) = if not(name.StartsWith("/")) then ("/" + name) else name
        let rec searchEntry (zip:ZipInputStream, current:ZipEntry) =
            match current.Name |> clean  with
                | name when name = path -> 
                    use memory = new MemoryStream()
                    zip.CopyTo(memory)
                    zip.Flush()
                    memory.Flush()
                    Some(memory.ToArray())
                | _ -> 
                    match zip.GetNextEntry() with 
                        | null -> None
                        | next -> searchEntry(zip, next)

        use fs = File.OpenRead(filepath)
        let s = new ZipInputStream(fs)
        searchEntry(s, s.GetNextEntry())

    let manifestDecode (data: byte array) = 
        // inpired by http://stackoverflow.com/a/19063830/2554459
        let endDocTag = 0x00100101
        let startTag = 0x00100102
        let endTag = 0x00100103

        let lew (arr:byte array, off:int) = 
            let p1 = uint32 (arr.[off + 3]) <<< 24 &&& 0xff000000u
            let p2 = uint32 (arr.[off + 2]) <<< 16 &&& 0xff0000u
            let p3 = uint32 (arr.[off + 1]) <<< 8 &&& 0xff00u
            let p4 = uint32 (arr.[off]) &&& 0xFFu
            p1 ||| p2 ||| p3 ||| p4 |> int32

        let firstTagOffset = 
            let rec scanStartOfFirstTag offset = 
                match lew(data, offset) with
                    | v when v = startTag -> Some (offset)
                    | _ when offset < data.Length - 4 -> scanStartOfFirstTag (offset + 4)
                    | _ -> None
            let s = lew(data, 3 * 4)
            match s |> scanStartOfFirstTag with
                | Some v -> v
                | None -> s

        let readStringAt offset =   
            let len = data.[offset + 1] <<< 8 &&& byte(0xff00) ||| data.[offset] &&& byte(0xff) |> int
            [|for i in 0..len-1 -> data.[offset + 2 + i * 2] |] |> System.Text.Encoding.UTF8.GetString

        let readString (sitOff:int, stOff:int, strInd:int) =
            if strInd < 0 then null else readStringAt (stOff + lew(data, sitOff + strInd * 4))

        let numbStrings = lew(data, 4 * 4)
        let sitOff = 0x24 // Offset of start of StringIndexTable
        let stOff = sitOff + numbStrings * 4 // StringTable follows StrIndexTable
        let xmlTagOff = firstTagOffset // Start from the offset in the 3rd word.
        let rec readNode acc off =
            let tag0 = lew(data, off)
            let lineNo = lew(data, off + 2 * 4)
            let nameSi = lew(data, off + 5 * 4)
            
            match tag0 with
                | t when t = startTag -> 
                    let numbAttrs = lew(data, off + 7 * 4)
                    let name = readString(sitOff, stOff, nameSi)
                    let mutable attrOff = off + 9 * 4
                    let sb = new StringBuilder(acc + "<" + name)
                    for i in 0..numbAttrs-1 do
                        let attrNameNsSi = lew(data, attrOff)
                        let attrNameSi = lew(data, attrOff + 1 * 4)
                        let attrValueSi = lew(data, attrOff + 2 * 4)
                        let attrFlags = lew(data, attrOff + 3 * 4)
                        let attrResId = lew(data, attrOff + 4 * 4)
                        let attrName = readString(sitOff, stOff, attrNameSi)
                        let attrValue = if not(attrValueSi = -1) then readString (sitOff, stOff, attrValueSi) else attrResId.ToString()
                        (" " + attrName + "=\"" + attrValue + "\"") |> sb.Append |> ignore
                        attrOff <- attrOff + 5 * 4
                    sb.Append(">") |> ignore
                    readNode (sb.ToString()) attrOff
                | t when t = endTag -> 
                    let name = readString(sitOff, stOff, nameSi)
                    readNode (acc + "</" + name + ">\r\n") (off + 6 * 4)
                | t when t = endDocTag -> acc
                | _ -> failwith "Invalid manifest format";

        readNode "" xmlTagOff

    let getManifest (filepath) =
        match getZipEntry (filepath, "/AndroidManifest.xml") with
            | Some bytes -> Some (bytes |> manifestDecode |> System.Xml.Linq.XDocument.Parse)
            | None -> None

    let header (s:ServiceSession) = sprintf "%s %s" s.TokenType s.Token
    let (<<) (headers:WebHeaderCollection) session = headers.Add("Authorization", header session)

    let createRsaCrypto (credentials:ServiceCredentials) = 
        let pkey = credentials.Certificate.PrivateKey :?> RSACryptoServiceProvider
        let blob = pkey.ExportCspBlob(true)
        let rsaCrypto = new RSACryptoServiceProvider()
        rsaCrypto.ImportCspBlob(blob)
        rsaCrypto

    let base64EncodeUrlBytes (b:byte[]) = b |> Convert.ToBase64String |> fun b64 -> b64.Replace("=", "").Replace('+', '-').Replace('/', '_')
    let base64EncodeUrl (s:string) = s |> Encoding.UTF8.GetBytes |> base64EncodeUrlBytes

    let toUnixTime d = (d - new DateTime(1970, 1, 1, 0, 0, 0, d.Kind)).TotalSeconds |> int64

    let postUrl (url:string, data:NameValueCollection) = 
        let client = new WebClient()
        client.UploadValues(url, data)

    let googleAuthenticate (credentials:ServiceCredentials) = 
        let header = ServiceAccountHeader |> toJson |> base64EncodeUrl
        let now = DateTime.UtcNow |> toUnixTime
        let claimSet = { AccountId = credentials.AccountId; Scope = AndroidPublisherScope; Audience = TokenServerUrl; Issue = now; Expiry = now + 3600L  }
        let payload = claimSet |> toJson |> base64EncodeUrl
        let rsa = createRsaCrypto credentials
        let assertion = header + "." + payload
        let bb = rsa.SignData(Encoding.ASCII.GetBytes(assertion), "SHA256")
        let signature = bb |> base64EncodeUrlBytes
        let signedAssertion = assertion + "." + signature
        let p = new NameValueCollection();
        p.Add("assertion", signedAssertion)
        p.Add("grant_type", "urn:ietf:params:oauth:grant-type:jwt-bearer")
        let auth = postUrl(TokenServerUrl, p) |> Encoding.UTF8.GetString |> fromJson<AuthReply>
        { Token = auth.AccessToken; TokenType = auth.TokenType; Expiry = DateTime.UtcNow.AddSeconds(auth.ExpiresIn |> float) }

    let appEditInsert (session:ServiceSession, packageName:string) =
        let client = new WebClient()
        client.Headers << session
        client.UploadString(AndroidPublisherBaseUrl + "/" + packageName + "/edits", "") |> fromJson<EditResourceModel>

    let appListApks (session:ServiceSession, packageName:string, editId:string) =
        let client = new WebClient()
        client.Headers << session
        client.DownloadString(AndroidPublisherBaseUrl + "/" + packageName + "/edits/" + editId + "/apks") |> fromJson<AppEditListApksResult>
    
    let validateAppEdit (session:ServiceSession, packageName:string, editId:string) =
        let client = new WebClient()
        client.Headers << session
        client.UploadString(AndroidPublisherBaseUrl + "/" + packageName + "/edits/" + editId + ":validate", "") |> fromJson<EditResourceModel>

    let commitAppEdit (session:ServiceSession, packageName:string, editId:string) =
        let client = new WebClient()
        client.Headers << session
        client.UploadString(AndroidPublisherBaseUrl + "/" + packageName + "/edits/" + editId + ":commit", "") |> fromJson<EditResourceModel>

    let setAppTrack (session:ServiceSession, packageName:string, editId:string, track:string, versionCode:int) =
        let client = new WebClient()
        client.Headers << session
        client.Headers.Add ("Content-Type", "application/json")
        let m = { Track = track; VersionCodes = [versionCode];(* UserFraction = 1.*) }
        let data = JsonConvert.SerializeObject(m)
        client.UploadString(AndroidPublisherBaseUrl + "/" + packageName + "/edits/" + editId + "/tracks/" + track, "PUT", data) |> fromJson<EditResourceModel>
    
    let fopen fn = File.Open(fn, FileMode.Open)

    let uploadApk (session:ServiceSession, packageName:string, editId:string, apkPath:string) =
        let url = new Uri(AndroidUploadApkBaseUrl + "/" + packageName + "/edits/" + editId + "/apks?uploadType=media")
        let client = new HttpClient()
        let watch = Stopwatch.StartNew()
        let rq = client.CreateRequest (url)
        rq.Method <- "POST"
        rq.ContentType <- "application/octet-stream"
        rq.Headers << session
        use file = fopen apkPath
        use binary = new BinaryReader(file)
        rq.ContentLength <- file.Length
        use stream = rq.GetRequestStream()
        use out = new BinaryWriter(stream)
        let mutable lastProgress = 0L

        while file.Position < file.Length do
            let uploaded = (file.Position/1024L/1024L)
            let bytes = binary.ReadBytes(512)
            out.Write(bytes)
            out.Flush()
            stream.Flush()
            let percent = 100L * file.Position / file.Length
            if lastProgress = uploaded |> not then
                let elapsed = watch.ElapsedMilliseconds
                let estimated = 100L*elapsed/percent
                tracefn "Estimated remaining time: %s" (TimeSpan.FromMilliseconds(float(estimated - elapsed)).ToString())
                tracefn @"Uploaded progress %d MB %d %%  ..." uploaded percent
            lastProgress <- uploaded
        stream.Flush()
        stream.Close()
        watch.Stop()
        tracefn "APK file sent"
        try
            let rs = rq.GetResponse()
            use reader = new StreamReader(rs.GetResponseStream())
            { Content=Some(fromJson<ApkVersion>(reader.ReadToEnd())); Error=None }
        with 
            | :? WebException as e -> 
                use reader = new StreamReader(e.Response.GetResponseStream())
                { Content=None; Error=Some(reader.ReadToEnd()) }
            | e -> { Content=None; Error=Some(e.Message) }


    let credentials = { Certificate = param.Config.Certificate; AccountId = param.Config.AccountId; }
    let session = credentials |> googleAuthenticate
    let resource = appEditInsert (session, param.Config.PackageName)
    let apkList = appListApks (session, param.Config.PackageName, resource.Id)
    let manifest = match param.Config.Apk |> getManifest with | Some xml -> xml | None -> failwithf "cannot parse apk AndroidManifest"
    let versionCode = (manifest.Element("manifest" |> XName.Get).Attributes() |> Seq.filter(fun a -> a.Name.LocalName = "versionCode") |> Seq.exactlyOne).Value |> Convert.ToInt32
    
    if apkList.Apks.Length > 0 && (apkList.Apks |> Seq.maxBy (fun a -> a.Code)).Code >= versionCode then
        failwithf "You must increase versionCode"                         

    let upResult = uploadApk (session, param.Config.PackageName, resource.Id, param.Config.Apk)
    match (upResult.Content, upResult.Error) with 
        | Some content, None -> 
            tracefn "upload success: version code %d \n" content.Code
            tracefn "setting track %s \n" param.Track
            setAppTrack (session, param.Config.PackageName, resource.Id, param.Track, content.Code) |> ignore
            tracefn "validating \n"
            validateAppEdit (session, param.Config.PackageName, resource.Id) |> ignore
            tracefn "committing app \n"
            commitAppEdit (session, param.Config.PackageName, resource.Id) |> ignore
        | None, Some error -> failwith error
        | _, _ -> failwith "upload failed"

    tracefn "app published"
